<?php

namespace Luracast\Restler;

use Exception;

/**
 * Parses the PHPDoc comments for metadata.
 * Inspired by `Documentor` code base.
 *
 * @category Framework
 * @package Restler
 * @subpackage Helper
 * @author R.Arul Kumaran <arul@luracast.com>
 * @copyright 2010 Luracast
 * @license http://www.opensource.org/licenses/lgpl-license.php LGPL
 * @link http://luracast.com/products/restler/
 * @version 3.0.0rc5
 */
class CommentParser {
	/**
	 * name for the embedded data
	 *
	 * @var string
	 */
	public static $embeddedDataName = 'properties';
	/**
	 * Regular Expression pattern for finding the embedded data and extract
	 * the inner information.
	 * It is used with preg_match.
	 *
	 * @var string
	 */
	public static $embeddedDataPattern = '/```(\w*)[\s]*(([^`]*`{0,2}[^`]+)*)```/ms';
	/**
	 * Pattern will have groups for the inner details of embedded data
	 * this index is used to locate the data portion.
	 *
	 * @var int
	 */
	public static $embeddedDataIndex = 2;
	/**
	 * Delimiter used to split the array data.
	 *
	 * When the name portion is of the embedded data is blank auto detection
	 * will be used and if URLEncodedFormat is detected as the data format
	 * the character specified will be used as the delimiter to find split
	 * array data.
	 *
	 * @var string
	 */
	public static $arrayDelimiter = ',';
	
	/**
	 * character sequence used to escape \@
	 */
	const escapedAtChar = '\\@';
	
	/**
	 * character sequence used to escape end of comment
	 */
	const escapedCommendEnd = '{@*}';
	
	/**
	 * Instance of Restler class injected at runtime.
	 *
	 * @var Restler
	 */
	public $restler;
	/**
	 * Comment information is parsed and stored in to this array.
	 *
	 * @var array
	 */
	private $_data = array ();
	
	/**
	 * Parse the comment and extract the data.
	 *
	 * @static
	 *
	 * @param
	 *        	$comment
	 * @param bool $isPhpDoc        	
	 *
	 * @return array associative array with the extracted values
	 */
	public static function parse($comment, $isPhpDoc = true) {
		$p = new self ();
		if (empty ( $comment )) {
			return $p->_data;
		}
		
		if ($isPhpDoc) {
			$comment = self::removeCommentTags ( $comment );
		}
		
		$p->extractData ( $comment );
		return $p->_data;
	}
	
	/**
	 * Removes the comment tags from each line of the comment.
	 *
	 * @static
	 *
	 * @param string $comment
	 *        	PhpDoc style comment
	 *        	
	 * @return string comments with out the tags
	 */
	public static function removeCommentTags($comment) {
		$pattern = '/(^\/\*\*)|(^\s*\**[ \/]?)|\s(?=@)|\s\*\//m';
		return preg_replace ( $pattern, '', $comment );
	}
	
	/**
	 * Extracts description and long description, uses other methods to get
	 * parameters.
	 *
	 * @param
	 *        	$comment
	 *        	
	 * @return array
	 */
	private function extractData($comment) {
		// to use @ as part of comment we need to
		$comment = str_replace ( array (
				self::escapedCommendEnd,
				self::escapedAtChar 
		), array (
				'*/',
				'@' 
		), $comment );
		
		$description = array ();
		$longDescription = array ();
		$params = array ();
		
		$mode = 0; // extract short description;
		$comments = preg_split ( "/(\r?\n)/", $comment );
		// remove first blank line;
		array_shift ( $comments );
		$addNewline = false;
		foreach ( $comments as $line ) {
			$line = trim ( $line );
			$newParam = false;
			if (empty ( $line )) {
				if ($mode == 0) {
					$mode ++;
				} else {
					$addNewline = true;
				}
				continue;
			} elseif ($line {0} == '@') {
				$mode = 2;
				$newParam = true;
			}
			switch ($mode) {
				case 0 :
					$description [] = $line;
					if (count ( $description ) > 3) {
						// if more than 3 lines take only first line
						$longDescription = $description;
						$description [] = array_shift ( $longDescription );
						$mode = 1;
					} elseif (substr ( $line, - 1 ) == '.') {
						$mode = 1;
					}
					break;
				case 1 :
					if ($addNewline) {
						$line = ' ' . $line;
					}
					$longDescription [] = $line;
					break;
				case 2 :
					$newParam ? $params [] = $line : $params [count ( $params ) - 1] .= ' ' . $line;
			}
			$addNewline = false;
		}
		$description = implode ( ' ', $description );
		$longDescription = implode ( ' ', $longDescription );
		$description = preg_replace ( '/\s+/msu', ' ', $description );
		$longDescription = preg_replace ( '/\s+/msu', ' ', $longDescription );
		list ( $description, $d1 ) = $this->parseEmbeddedData ( $description );
		list ( $longDescription, $d2 ) = $this->parseEmbeddedData ( $longDescription );
		$this->_data = compact ( 'description', 'longDescription' );
		$d2 += $d1;
		if (! empty ( $d2 )) {
			$this->_data [self::$embeddedDataName] = $d2;
		}
		foreach ( $params as $key => $line ) {
			list ( , $param, $value ) = preg_split ( '/\@|\s/', $line, 3 ) + array (
					'',
					'',
					'' 
			);
			list ( $value, $embedded ) = $this->parseEmbeddedData ( $value );
			$value = array_filter ( preg_split ( '/\s+/msu', $value ) );
			$this->parseParam ( $param, $value, $embedded );
		}
		return $this->_data;
	}
	
	/**
	 * Parse parameters that begin with (at)
	 *
	 * @param
	 *        	$param
	 * @param array $value        	
	 * @param array $embedded        	
	 */
	private function parseParam($param, array $value, array $embedded) {
		$data = & $this->_data;
		$allowMultiple = false;
		switch ($param) {
			case 'param' :
				$value = $this->formatParam ( $value );
				$allowMultiple = true;
				break;
			case 'var' :
				$value = $this->formatVar ( $value );
				break;
			case 'return' :
				$value = $this->formatReturn ( $value );
				break;
			case 'class' :
				$data = & $data [$param];
				list ( $param, $value ) = $this->formatClass ( $value );
				break;
			case 'access' :
				$value = reset ( $value );
				break;
			case 'expires' :
			case 'status' :
				$value = intval ( reset ( $value ) );
				break;
			case 'throws' :
				$value = $this->formatThrows ( $value );
				$allowMultiple = true;
				break;
			case 'author' :
				$value = $this->formatAuthor ( $value );
				$allowMultiple = true;
				break;
			case 'header' :
			case 'link' :
			case 'example' :
			case 'todo' :
				$allowMultiple = true;
			// don't break, continue with code for default:
			default :
				$value = implode ( ' ', $value );
		}
		if (! empty ( $embedded )) {
			if (is_string ( $value )) {
				$value = array (
						'description' => $value 
				);
			}
			$value [self::$embeddedDataName] = $embedded;
		}
		if (empty ( $data [$param] )) {
			if ($allowMultiple) {
				$data [$param] = array (
						$value 
				);
			} else {
				$data [$param] = $value;
			}
		} elseif ($allowMultiple) {
			$data [$param] [] = $value;
		} elseif ($param == 'param') {
			$arr = array (
					$data [$param],
					$value 
			);
			$data [$param] = $arr;
		} else {
			if (! is_string ( $value ) && isset ( $value [self::$embeddedDataName] ) && isset ( $data [$param] [self::$embeddedDataName] )) {
				$value [self::$embeddedDataName] += $data [$param] [self::$embeddedDataName];
			}
			$data [$param] = $value + $data [$param];
		}
	}
	
	/**
	 * Parses the inline php doc comments and embedded data.
	 *
	 * @param
	 *        	$subject
	 *        	
	 * @return array
	 * @throws Exception
	 */
	private function parseEmbeddedData($subject) {
		$data = array ();
		
		// parse {@pattern } tags specially
		while ( preg_match ( '|(?s-m)({@pattern (/.+/[imsxuADSUXJ]*)})|', $subject, $matches ) ) {
			$subject = str_replace ( $matches [0], '', $subject );
			$data ['pattern'] = $matches [2];
		}
		while ( preg_match ( '/{@(\w+)\s?([^}]*)}/ms', $subject, $matches ) ) {
			$subject = str_replace ( $matches [0], '', $subject );
			if ($matches [2] == 'true' || $matches [2] == 'false') {
				$matches [2] = $matches [2] == 'true';
			} elseif ($matches [2] == '') {
				$matches [2] = true;
			}
			if ($matches [1] == 'pattern') {
				throw new Exception ( 'Inline pattern tag should follow {@pattern /REGEX_PATTERN_HERE/} format and can optionally include PCRE modifiers following the ending `/`' );
			} elseif (false !== strpos ( $matches [2], static::$arrayDelimiter )) {
				$matches [2] = explode ( static::$arrayDelimiter, $matches [2] );
			}
			$data [$matches [1]] = $matches [2];
		}
		
		while ( preg_match ( self::$embeddedDataPattern, $subject, $matches ) ) {
			$subject = str_replace ( $matches [0], '', $subject );
			$str = $matches [self::$embeddedDataIndex];
			if (isset ( $this->restler ) && self::$embeddedDataIndex > 1 && ! empty ( $matches [1] )) {
				$extension = $matches [1];
				$formatMap = $this->restler->getFormatMap ();
				if (isset ( $formatMap [$extension] )) {
					/**
					 *
					 * @var \Luracast\Restler\Format\iFormat
					 */
					$format = $formatMap [$extension];
					$format = new $format ();
					$data = $format->decode ( $str );
				}
			} else { // auto detect
				if ($str {0} == '{') {
					$d = json_decode ( $str, true );
					if (json_last_error () != JSON_ERROR_NONE) {
						throw new Exception ( 'Error parsing embedded JSON data' . " $str" );
					}
					$data = $d + $data;
				} else {
					parse_str ( $str, $d );
					// clean up
					$d = array_filter ( $d );
					foreach ( $d as $key => $val ) {
						$kt = trim ( $key );
						if ($kt != $key) {
							unset ( $d [$key] );
							$key = $kt;
							$d [$key] = $val;
						}
						if (is_string ( $val )) {
							if ($val == 'true' || $val == 'false') {
								$d [$key] = $val == 'true' ? true : false;
							} else {
								$val = explode ( self::$arrayDelimiter, $val );
								if (count ( $val ) > 1) {
									$d [$key] = $val;
								} else {
									$d [$key] = preg_replace ( '/\s+/msu', ' ', $d [$key] );
								}
							}
						}
					}
					$data = $d + $data;
				}
			}
		}
		return array (
				$subject,
				$data 
		);
	}
	private function formatThrows(array $value) {
		$r = array ();
		$r ['code'] = count ( $value ) && is_numeric ( $value [0] ) ? intval ( array_shift ( $value ) ) : 500;
		$reason = implode ( ' ', $value );
		$r ['reason'] = empty ( $reason ) ? '' : $reason;
		return $r;
	}
	private function formatClass(array $value) {
		$param = array_shift ( $value );
		
		if (empty ( $param )) {
			$param = 'Unknown';
		}
		$value = implode ( ' ', $value );
		return array (
				ltrim ( $param, '\\' ),
				array (
						'description' => $value 
				) 
		);
	}
	private function formatAuthor(array $value) {
		$r = array ();
		$email = end ( $value );
		if ($email {0} == '<') {
			$email = substr ( $email, 1, - 1 );
			array_pop ( $value );
			$r ['email'] = $email;
		}
		$r ['name'] = implode ( ' ', $value );
		return $r;
	}
	private function formatReturn(array $value) {
		$data = explode ( '|', array_shift ( $value ) );
		$r = array (
				'type' => count ( $data ) == 1 ? $data [0] : $data 
		);
		$r ['description'] = implode ( ' ', $value );
		return $r;
	}
	private function formatParam(array $value) {
		$r = array ();
		$data = array_shift ( $value );
		if (empty ( $data )) {
			$r ['type'] = 'mixed';
		} elseif ($data {0} == '$') {
			$r ['name'] = substr ( $data, 1 );
			$r ['type'] = 'mixed';
		} else {
			$data = explode ( '|', $data );
			$r ['type'] = count ( $data ) == 1 ? $data [0] : $data;
			
			$data = array_shift ( $value );
			if (! empty ( $data ) && $data {0} == '$') {
				$r ['name'] = substr ( $data, 1 );
			}
		}
		if ($value) {
			$r ['description'] = implode ( ' ', $value );
		}
		return $r;
	}
	private function formatVar(array $value) {
		$r = array ();
		$data = array_shift ( $value );
		if (empty ( $data )) {
			$r ['type'] = 'mixed';
		} elseif ($data {0} == '$') {
			$r ['name'] = substr ( $data, 1 );
			$r ['type'] = 'mixed';
		} else {
			$data = explode ( '|', $data );
			$r ['type'] = count ( $data ) == 1 ? $data [0] : $data;
		}
		if ($value) {
			$r ['description'] = implode ( ' ', $value );
		}
		return $r;
	}
}
